feat(ClientController): init controller#7808
Conversation
c2404be to
7ff3a8a
Compare
|
I plan on reviewing this but first I want to better understand the problems you see this controller solving and how you anticipate it being used. In the explanation for this PR, you say that as new controllers with data subscriptions are added, clients must be updated to inform those controllers to start when the UI is active and stop when the UI is not. You say that this isn't scalable because this code is scattered throughout the clients. Furthermore, in the technical proposal you created earlier, you seem to say that the UI should not be responsible for managing data subscriptions, the controller layer should. This is further implied in this PR when suggesting that with the existence of an ApplicationStateController, other controllers can now be aware of when the UI is open and automatically start and stop data subscriptions. First, I don't think it is bad that there is some code in the UI which controls when a data subscription is started or stopped. After all, not every screen needs every piece of data we use throughout MetaMask, so it seems perfectly fine to me to place that control at the React component level (i.e., "when this component mounts, start polling or open a WebSocket; when this components unmounts, stop"). My sense is that the problem we're trying to solve here is not how to handle what happens if a user switches away from a screen, but how to handle when the app (whether that's the mobile app, or a tab in a browser) is backgrounded or foregrounded. Here, the user is still on a screen — so no component is unmounted — but the user is not actively using the app. And in this case, it seems to me that if the current screen is actively polling for data or has a WebSocket open, then that data subscription needs to be paused, so that when the user returns, it can be resumed. In other words, alongside this controller I wonder if we ought to update the pattern that we've been using so that polling controllers, WebSocket services, and other places have both a I think reframing this controller — and the examples presented in places like the README — in this way would make more sense to me, but what do you think? |
packages/application-state-controller/src/ApplicationStateController.ts
Outdated
Show resolved
Hide resolved
Currently, when you look at the core codebase, you don't have a complete picture of how/when some code is executed. This is extremely hard to understand the whole workflow when you don't know that some logic lives in the UI. To me, it's the same utility as when we switch accounts: we have a state and events that we can listen to in any Controller, with no UI code/hooks needed. Thanks to this App State Event, we could consider removing all polling logic from the extension and mobile, which represents quite some code to clean, but ultimately will make platform code lighter. |
|
.@Kriys94 and I spoke earlier today about this. For completeness here is more or less a summary of what I said: Implications on polling controllersTo be clear, I don't think this controller is not needed. I can see the value in encapsulating the concept of whether the user is currently using MetaMask so that we can access that information in an agnostic fashion. However, there are caveats around how this controller ought to be used that I think should be highlighted or at least addressed somewhere. Namely, when it comes to polling controllers, I don't know that we would want to suggest that engineers can update their controllers to start polling when MetaMask is active and stop polling when it's inactive, e.g.: this.#messenger.subscribe(
'ApplicationStateController:stateChange',
(isClientOpen) => {
if (isClientOpen) {
this.#start();
} else {
this.#stop();
}
},
(state) => state.isClientOpen
);This pattern implies that we would be moving polling management to a more global location: as soon as MetaMask opens, no matter what screen the user is on, we start subscribing to updates for all kinds of data — even for ones that the current screen doesn't need. I don't feel like this is a good strategy long-term. We've gotten in trouble in the past about making network requests before users complete onboarding and I feel like we would run into that again if we went that direction. That's why I suggested continuing to have the UI drive which polls are active, but then introducing the idea of "pausing" and "resuming", for instance: this.#messenger.subscribe(
'ApplicationStateController:stateChange',
(isClientOpen) => {
if (isClientOpen) {
this.#resume();
} else {
this.#pause();
}
},
(state) => state.isClientOpen
);If we are providing examples for how to use this controller in this PR, then I think we should suggest that engineers follow these practices. NamingThe other thing I mentioned in the call is around naming:
I think the name I also wonder if we should rename the state property? I am fine with the word "open". On extension, it could reasonably mean that the popup is open, or the full-screen view is open (regardless of whether the tab is active), or the sidepanel is open. And on mobile, it would mean that the app is foregrounded. But, "client" seems like a synonym for "application" to me. So maybe we should rename the state property either |
packages/application-state-controller/src/ApplicationStateController.test.ts
Outdated
Show resolved
Hide resolved
packages/client-state-controller/src/ClientStateController.test.ts
Outdated
Show resolved
Hide resolved
packages/client-state-controller/src/ClientStateController.test.ts
Outdated
Show resolved
Hide resolved
packages/client-state-controller/src/ClientStateController.test.ts
Outdated
Show resolved
Hide resolved
packages/client-state-controller/src/ClientStateController.test.ts
Outdated
Show resolved
Hide resolved
876c111 to
3620a98
Compare
|
@mcmire For the naming, I called it ClientStateController. WDYT? |
7dff417 to
4f0fa7d
Compare
7afa7ef to
47c96d0
Compare
a646de4 to
1a2635c
Compare
mcmire
left a comment
There was a problem hiding this comment.
I noticed two more changes (not sure if they were present before or not). After this it should be good to go from me.
407a697 to
73385cb
Compare
73385cb to
f0a8653
Compare
|
No dependency changes detected. Learn more about Socket for GitHub. 👍 No dependency changes detected in pull request |
f0a8653 to
d21efe7
Compare
d21efe7 to
437f480
Compare
## Explanation **Current state:** Application lifecycle management (knowing when the UI is open or closed) is currently scattered across platform-specific code (Extension and Mobile). When the UI state changes, MetamaskController must manually notify each controller/service that cares about this state via imperative calls. **Problem:** This approach doesn't scale well. Every time a new controller needs to respond to client state changes (stop polling, disconnect WebSockets, pause subscriptions), the platform code has to be modified to add another manual call. Platform code becomes tightly coupled to controller-specific behavior. **Solution:** This PR introduces `@metamask/ui-state-controller`, a new shared controller that centralizes application lifecycle state. The pattern follows an inversion of control approach: 1. Platform code calls a single messenger action: `UiStateController:setUiOpen` 2. Consumer controllers subscribe to `UiStateController:stateChange` and manage themselves 3. **Platform code no longer needs controller-specific logic** (e.g., starting/stopping polling, connecting/disconnecting WebSockets) — controllers handle this internally by reacting to state changes This enables polling controllers to stop when the client closes, WebSocket connections to disconnect, and real-time subscriptions to pause—all without modifying platform code, with the logic encapsulated in core. **State properties:** - `isUiOpen: boolean` — Whether the client (UI) is currently open (not persisted, always starts as `false`) **Messenger API:** - Action: `UiStateController:setUiOpen` — Called by platform code - Event: `UiStateController:stateChange` — Subscribed to by consumer controllers ## References - Related Extension PR: MetaMask/metamask-extension#39703 - Related Mobile PR: MetaMask/metamask-mobile#25517 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Mostly additive new package and metadata/docs wiring with minimal impact on existing runtime behavior; risk is limited to new consumers depending on the new messenger action/event contract. > > **Overview** > Introduces a new package, `@metamask/client-controller`, providing a `ClientController` that tracks whether the MetaMask UI is open (`isUiOpen`, non-persisted) and exposes a messenger action `ClientController:setUiOpen` plus the standard `ClientController:stateChange` event for consumers. > > Adds accompanying exports (types + `clientControllerSelectors.selectIsUiOpen`), 100%-coverage unit tests, and package scaffolding (README, changelog, license, Jest/TypeDoc/TS configs, `package.json`). Updates monorepo wiring/metadata (`tsconfig` references, `yarn.lock`, `CODEOWNERS`, `teams.json`) and regenerates the root `README.md` dependency graph/package list content. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 437f480. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Explanation
Current state: Application lifecycle management (knowing when the UI is open or closed) is currently scattered across platform-specific code (Extension and Mobile). When the UI state changes, MetamaskController must manually notify each controller/service that cares about this state via imperative calls.
Problem: This approach doesn't scale well. Every time a new controller needs to respond to client state changes (stop polling, disconnect WebSockets, pause subscriptions), the platform code has to be modified to add another manual call. Platform code becomes tightly coupled to controller-specific behavior.
Solution: This PR introduces
@metamask/ui-state-controller, a new shared controller that centralizes application lifecycle state. The pattern follows an inversion of control approach:UiStateController:setUiOpenUiStateController:stateChangeand manage themselvesThis enables polling controllers to stop when the client closes, WebSocket connections to disconnect, and real-time subscriptions to pause—all without modifying platform code, with the logic encapsulated in core.
State properties:
isUiOpen: boolean— Whether the client (UI) is currently open (not persisted, always starts asfalse)Messenger API:
UiStateController:setUiOpen— Called by platform codeUiStateController:stateChange— Subscribed to by consumer controllersReferences
Checklist
Note
Low Risk
Mostly additive new package and metadata/docs wiring with minimal impact on existing runtime behavior; risk is limited to new consumers depending on the new messenger action/event contract.
Overview
Introduces a new package,
@metamask/client-controller, providing aClientControllerthat tracks whether the MetaMask UI is open (isUiOpen, non-persisted) and exposes a messenger actionClientController:setUiOpenplus the standardClientController:stateChangeevent for consumers.Adds accompanying exports (types +
clientControllerSelectors.selectIsUiOpen), 100%-coverage unit tests, and package scaffolding (README, changelog, license, Jest/TypeDoc/TS configs,package.json). Updates monorepo wiring/metadata (tsconfigreferences,yarn.lock,CODEOWNERS,teams.json) and regenerates the rootREADME.mddependency graph/package list content.Written by Cursor Bugbot for commit 437f480. This will update automatically on new commits. Configure here.